Bez popisu

[id].tsx 22KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. import { useEffect, useMemo, useState } from 'react';
  2. import {
  3. Alert,
  4. Image,
  5. KeyboardAvoidingView,
  6. Modal,
  7. Platform,
  8. Pressable,
  9. ScrollView,
  10. StyleSheet,
  11. TextInput,
  12. View,
  13. } from 'react-native';
  14. import * as ImagePicker from 'expo-image-picker';
  15. import DateTimePicker from '@react-native-community/datetimepicker';
  16. import { ResizeMode, Video } from 'expo-av';
  17. import { useLocalSearchParams, useRouter } from 'expo-router';
  18. import { ThemedButton } from '@/components/themed-button';
  19. import { IconButton } from '@/components/icon-button';
  20. import { ThemedText } from '@/components/themed-text';
  21. import { ThemedView } from '@/components/themed-view';
  22. import { ZoomImageModal } from '@/components/zoom-image-modal';
  23. import { Colors } from '@/constants/theme';
  24. import { useColorScheme } from '@/hooks/use-color-scheme';
  25. import { useTranslation } from '@/localization/i18n';
  26. import { dbPromise, initCoreTables } from '@/services/db';
  27. const CATEGORY_PRESETS = ['seed', 'fertilizer', 'labor', 'fuel', 'equipment', 'transport', 'misc'];
  28. type FieldRow = {
  29. id: number;
  30. name: string | null;
  31. };
  32. type CropRow = {
  33. id: number;
  34. crop_name: string | null;
  35. };
  36. type CostRow = {
  37. id: number;
  38. field_id: number | null;
  39. crop_id: number | null;
  40. category: string | null;
  41. amount: number | null;
  42. currency: string | null;
  43. vendor: string | null;
  44. notes: string | null;
  45. spent_at: string | null;
  46. photo_uri: string | null;
  47. };
  48. type MediaRow = {
  49. uri: string | null;
  50. };
  51. export default function CostDetailScreen() {
  52. const { t } = useTranslation();
  53. const router = useRouter();
  54. const { id } = useLocalSearchParams<{ id?: string | string[] }>();
  55. const costId = Number(Array.isArray(id) ? id[0] : id);
  56. const theme = useColorScheme() ?? 'light';
  57. const palette = Colors[theme];
  58. const [loading, setLoading] = useState(true);
  59. const [status, setStatus] = useState('');
  60. const [fields, setFields] = useState<FieldRow[]>([]);
  61. const [crops, setCrops] = useState<CropRow[]>([]);
  62. const [currency, setCurrency] = useState('THB');
  63. const [fieldModalOpen, setFieldModalOpen] = useState(false);
  64. const [cropModalOpen, setCropModalOpen] = useState(false);
  65. const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
  66. const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
  67. const [category, setCategory] = useState('');
  68. const [amount, setAmount] = useState('');
  69. const [vendor, setVendor] = useState('');
  70. const [notes, setNotes] = useState('');
  71. const [spentDate, setSpentDate] = useState('');
  72. const [showSpentPicker, setShowSpentPicker] = useState(false);
  73. const [mediaUris, setMediaUris] = useState<string[]>([]);
  74. const [activeUri, setActiveUri] = useState<string | null>(null);
  75. const [errors, setErrors] = useState<{ field?: string; amount?: string }>({});
  76. const [zoomUri, setZoomUri] = useState<string | null>(null);
  77. const [saving, setSaving] = useState(false);
  78. const [showSaved, setShowSaved] = useState(false);
  79. useEffect(() => {
  80. let isActive = true;
  81. async function loadCost() {
  82. try {
  83. await initCoreTables();
  84. const db = await dbPromise;
  85. const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
  86. const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
  87. const profileRow = await db.getFirstAsync<{ currency: string | null }>(
  88. 'SELECT currency FROM user_profile WHERE id = 1;'
  89. );
  90. const rows = await db.getAllAsync<CostRow>(
  91. `SELECT id, field_id, crop_id, category, amount, currency, vendor, notes, spent_at, photo_uri
  92. FROM costs WHERE id = ? LIMIT 1;`,
  93. costId
  94. );
  95. if (!isActive) return;
  96. setFields(fieldRows);
  97. setCrops(cropRows);
  98. setCurrency(profileRow?.currency ?? 'THB');
  99. const cost = rows[0];
  100. if (!cost) {
  101. setStatus(t('costs.empty'));
  102. setLoading(false);
  103. return;
  104. }
  105. setSelectedFieldId(cost.field_id ?? null);
  106. setSelectedCropId(cost.crop_id ?? null);
  107. setCategory(cost.category ?? '');
  108. setAmount(cost.amount !== null ? String(cost.amount) : '');
  109. setVendor(cost.vendor ?? '');
  110. setNotes(cost.notes ?? '');
  111. setSpentDate(cost.spent_at ?? '');
  112. const mediaRows = await db.getAllAsync<MediaRow>(
  113. 'SELECT uri FROM cost_media WHERE cost_id = ? ORDER BY created_at ASC;',
  114. costId
  115. );
  116. const media = uniqueMediaUris([
  117. ...mediaRows.map((row) => row.uri).filter(Boolean),
  118. ...(normalizeMediaUri(cost.photo_uri) ? [normalizeMediaUri(cost.photo_uri) as string] : []),
  119. ] as string[]);
  120. setMediaUris(media);
  121. setActiveUri(media[0] ?? normalizeMediaUri(cost.photo_uri));
  122. } catch (error) {
  123. if (isActive) setStatus(`Error: ${String(error)}`);
  124. } finally {
  125. if (isActive) setLoading(false);
  126. }
  127. }
  128. loadCost();
  129. return () => {
  130. isActive = false;
  131. };
  132. }, [costId, t]);
  133. const selectedField = useMemo(
  134. () => fields.find((item) => item.id === selectedFieldId),
  135. [fields, selectedFieldId]
  136. );
  137. const selectedCrop = useMemo(
  138. () => crops.find((item) => item.id === selectedCropId),
  139. [crops, selectedCropId]
  140. );
  141. const inputStyle = [
  142. styles.input,
  143. {
  144. borderColor: palette.border,
  145. backgroundColor: palette.input,
  146. color: palette.text,
  147. },
  148. ];
  149. async function handleUpdate() {
  150. const parsedAmount = amount.trim() ? Number(amount) : null;
  151. const nextErrors: { field?: string; amount?: string } = {};
  152. if (!selectedFieldId) nextErrors.field = t('costs.fieldRequired');
  153. if (!parsedAmount || !Number.isFinite(parsedAmount)) nextErrors.amount = t('costs.amountInvalid');
  154. setErrors(nextErrors);
  155. if (Object.keys(nextErrors).length > 0) return;
  156. try {
  157. setSaving(true);
  158. const db = await dbPromise;
  159. const primaryUri = mediaUris[0] ?? normalizeMediaUri(activeUri);
  160. await db.runAsync(
  161. 'UPDATE costs SET field_id = ?, crop_id = ?, category = ?, amount = ?, currency = ?, vendor = ?, notes = ?, spent_at = ?, photo_uri = ? WHERE id = ?;',
  162. selectedFieldId,
  163. selectedCropId,
  164. category.trim() || null,
  165. parsedAmount,
  166. currency,
  167. vendor.trim() || null,
  168. notes.trim() || null,
  169. spentDate || null,
  170. primaryUri ?? null,
  171. costId
  172. );
  173. await db.runAsync('DELETE FROM cost_media WHERE cost_id = ?;', costId);
  174. const now = new Date().toISOString();
  175. const mediaToInsert = uniqueMediaUris([
  176. ...mediaUris,
  177. ...(normalizeMediaUri(activeUri) ? [normalizeMediaUri(activeUri) as string] : []),
  178. ]);
  179. for (const uri of mediaToInsert) {
  180. await db.runAsync(
  181. 'INSERT INTO cost_media (cost_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
  182. costId,
  183. uri,
  184. isVideoUri(uri) ? 'video' : 'image',
  185. now
  186. );
  187. }
  188. setStatus(t('costs.saved'));
  189. setShowSaved(true);
  190. setTimeout(() => {
  191. setShowSaved(false);
  192. setStatus('');
  193. }, 1800);
  194. } catch (error) {
  195. setStatus(`Error: ${String(error)}`);
  196. } finally {
  197. setSaving(false);
  198. }
  199. }
  200. async function handleDelete() {
  201. Alert.alert(t('costs.deleteTitle'), t('costs.deleteMessage'), [
  202. { text: t('costs.cancel'), style: 'cancel' },
  203. {
  204. text: t('costs.delete'),
  205. style: 'destructive',
  206. onPress: async () => {
  207. const db = await dbPromise;
  208. await db.runAsync('DELETE FROM costs WHERE id = ?;', costId);
  209. router.back();
  210. },
  211. },
  212. ]);
  213. }
  214. if (loading) {
  215. return (
  216. <ThemedView style={[styles.container, { backgroundColor: palette.background, padding: 16 }]}>
  217. <ThemedText>{t('costs.loading')}</ThemedText>
  218. </ThemedView>
  219. );
  220. }
  221. return (
  222. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  223. <KeyboardAvoidingView
  224. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  225. style={styles.keyboardAvoid}>
  226. <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
  227. <ThemedText type="title">{t('costs.edit')}</ThemedText>
  228. {status && !showSaved ? <ThemedText>{status}</ThemedText> : null}
  229. <ThemedText>
  230. {t('costs.field')}
  231. <ThemedText style={styles.requiredMark}> *</ThemedText>
  232. </ThemedText>
  233. <ThemedButton
  234. title={selectedField?.name || t('costs.selectField')}
  235. onPress={() => setFieldModalOpen(true)}
  236. variant="secondary"
  237. />
  238. {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
  239. <ThemedText>{t('costs.crop')}</ThemedText>
  240. <ThemedButton
  241. title={selectedCrop?.crop_name || t('costs.selectCrop')}
  242. onPress={() => setCropModalOpen(true)}
  243. variant="secondary"
  244. />
  245. <ThemedText>{t('costs.category')}</ThemedText>
  246. <View style={styles.chipRow}>
  247. {CATEGORY_PRESETS.map((preset) => {
  248. const label = t(`costs.category.${preset}`);
  249. const isActive = label === category || preset === category;
  250. return (
  251. <Pressable
  252. key={preset}
  253. style={[styles.chip, isActive ? styles.chipActive : null]}
  254. onPress={() => setCategory(label)}>
  255. <ThemedText style={styles.chipText}>{label}</ThemedText>
  256. </Pressable>
  257. );
  258. })}
  259. </View>
  260. <TextInput
  261. value={category}
  262. onChangeText={setCategory}
  263. placeholder={t('costs.categoryPlaceholder')}
  264. placeholderTextColor={palette.placeholder}
  265. style={inputStyle}
  266. />
  267. <ThemedText>
  268. {t('costs.amount')} ({currency})
  269. <ThemedText style={styles.requiredMark}> *</ThemedText>
  270. </ThemedText>
  271. <TextInput
  272. value={amount}
  273. onChangeText={(value) => {
  274. setAmount(value);
  275. if (errors.amount) setErrors((prev) => ({ ...prev, amount: undefined }));
  276. }}
  277. placeholder={t('costs.amountPlaceholder')}
  278. placeholderTextColor={palette.placeholder}
  279. style={inputStyle}
  280. keyboardType="decimal-pad"
  281. />
  282. {errors.amount ? <ThemedText style={styles.errorText}>{errors.amount}</ThemedText> : null}
  283. <ThemedText>{t('costs.vendor')}</ThemedText>
  284. <TextInput
  285. value={vendor}
  286. onChangeText={setVendor}
  287. placeholder={t('costs.vendorPlaceholder')}
  288. placeholderTextColor={palette.placeholder}
  289. style={inputStyle}
  290. />
  291. <ThemedText>{t('costs.date')}</ThemedText>
  292. <Pressable onPress={() => setShowSpentPicker(true)} style={styles.dateInput}>
  293. <ThemedText style={styles.dateValue}>
  294. {spentDate || t('costs.datePlaceholder')}
  295. </ThemedText>
  296. </Pressable>
  297. {showSpentPicker ? (
  298. <DateTimePicker
  299. value={spentDate ? new Date(spentDate) : new Date()}
  300. mode="date"
  301. onChange={(event, date) => {
  302. setShowSpentPicker(false);
  303. if (date) setSpentDate(toDateOnly(date));
  304. }}
  305. />
  306. ) : null}
  307. <ThemedText>{t('costs.notes')}</ThemedText>
  308. <TextInput
  309. value={notes}
  310. onChangeText={setNotes}
  311. placeholder={t('costs.notesPlaceholder')}
  312. placeholderTextColor={palette.placeholder}
  313. style={[inputStyle, styles.multiline]}
  314. multiline
  315. />
  316. <ThemedText>{t('costs.addMedia')}</ThemedText>
  317. {normalizeMediaUri(activeUri) ? (
  318. isVideoUri(normalizeMediaUri(activeUri) as string) ? (
  319. <Video
  320. source={{ uri: normalizeMediaUri(activeUri) as string }}
  321. style={styles.mediaPreview}
  322. useNativeControls
  323. resizeMode={ResizeMode.CONTAIN}
  324. />
  325. ) : (
  326. <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
  327. <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
  328. </Pressable>
  329. )
  330. ) : (
  331. <ThemedView style={[styles.mediaPlaceholder, { borderColor: palette.border }]}>
  332. <ThemedText style={styles.mediaPlaceholderText}>{t('costs.noPhoto')}</ThemedText>
  333. </ThemedView>
  334. )}
  335. {mediaUris.length > 0 ? (
  336. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaRow}>
  337. {mediaUris.map((uri) => (
  338. <Pressable
  339. key={uri}
  340. style={styles.mediaThumbWrap}
  341. onPress={() => setActiveUri(uri)}>
  342. {isVideoUri(uri) ? (
  343. <Video
  344. source={{ uri }}
  345. style={styles.mediaThumb}
  346. resizeMode={ResizeMode.COVER}
  347. isMuted
  348. />
  349. ) : (
  350. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  351. )}
  352. <Pressable
  353. style={styles.mediaRemove}
  354. onPress={() => {
  355. setMediaUris((prev) => {
  356. const next = prev.filter((item) => item !== uri);
  357. if (activeUri === uri) setActiveUri(next[0] ?? null);
  358. return next;
  359. });
  360. }}>
  361. <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
  362. </Pressable>
  363. </Pressable>
  364. ))}
  365. </ScrollView>
  366. ) : null}
  367. <View style={styles.mediaButtons}>
  368. <ThemedButton
  369. title={t('costs.pickFromGallery')}
  370. onPress={() =>
  371. handlePickMedia((uris) => {
  372. setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  373. if (!activeUri && uris[0]) setActiveUri(uris[0]);
  374. })
  375. }
  376. variant="secondary"
  377. />
  378. <ThemedButton
  379. title={t('costs.takeMedia')}
  380. onPress={() =>
  381. handleTakeMedia((uri) => {
  382. if (!uri) return;
  383. setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  384. if (!activeUri) setActiveUri(uri);
  385. })
  386. }
  387. variant="secondary"
  388. />
  389. </View>
  390. <View style={styles.actions}>
  391. <IconButton
  392. name="trash"
  393. onPress={handleDelete}
  394. accessibilityLabel={t('costs.delete')}
  395. variant="danger"
  396. />
  397. <View style={styles.updateGroup}>
  398. {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('costs.saved')}</ThemedText> : null}
  399. <ThemedButton
  400. title={saving ? t('costs.saving') : t('costs.update')}
  401. onPress={handleUpdate}
  402. />
  403. </View>
  404. </View>
  405. </ScrollView>
  406. </KeyboardAvoidingView>
  407. <Modal transparent visible={fieldModalOpen} animationType="fade">
  408. <Pressable style={styles.modalOverlay} onPress={() => setFieldModalOpen(false)}>
  409. <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
  410. <ThemedText type="subtitle">{t('costs.selectField')}</ThemedText>
  411. <ScrollView>
  412. {fields.map((item) => (
  413. <Pressable
  414. key={item.id}
  415. onPress={() => {
  416. setSelectedFieldId(item.id);
  417. setErrors((prev) => ({ ...prev, field: undefined }));
  418. setFieldModalOpen(false);
  419. }}
  420. style={styles.modalItem}>
  421. <ThemedText>{item.name || t('costs.untitled')}</ThemedText>
  422. </Pressable>
  423. ))}
  424. </ScrollView>
  425. </View>
  426. </Pressable>
  427. </Modal>
  428. <Modal transparent visible={cropModalOpen} animationType="fade">
  429. <Pressable style={styles.modalOverlay} onPress={() => setCropModalOpen(false)}>
  430. <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
  431. <ThemedText type="subtitle">{t('costs.selectCrop')}</ThemedText>
  432. <ScrollView>
  433. {crops.map((item) => (
  434. <Pressable
  435. key={item.id}
  436. onPress={() => {
  437. setSelectedCropId(item.id);
  438. setCropModalOpen(false);
  439. }}
  440. style={styles.modalItem}>
  441. <ThemedText>{item.crop_name || t('costs.untitled')}</ThemedText>
  442. </Pressable>
  443. ))}
  444. </ScrollView>
  445. </View>
  446. </Pressable>
  447. </Modal>
  448. <ZoomImageModal visible={Boolean(zoomUri)} uri={zoomUri} onClose={() => setZoomUri(null)} />
  449. </ThemedView>
  450. );
  451. }
  452. async function handlePickMedia(onAdd: (uris: string[]) => void) {
  453. const pickerTypes =
  454. (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
  455. (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
  456. undefined;
  457. const mediaTypes =
  458. pickerTypes?.Images && pickerTypes?.Videos
  459. ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
  460. : ['images', 'videos'];
  461. const result = await ImagePicker.launchImageLibraryAsync({
  462. mediaTypes,
  463. quality: 1,
  464. allowsMultipleSelection: true,
  465. });
  466. if (result.canceled) return;
  467. const uris = result.assets.map((asset) => asset.uri).filter(Boolean);
  468. if (uris.length > 0) onAdd(uris);
  469. }
  470. async function handleTakeMedia(onAdd: (uri: string | null) => void) {
  471. const pickerTypes =
  472. (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
  473. (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
  474. undefined;
  475. const mediaTypes =
  476. pickerTypes?.Images && pickerTypes?.Videos
  477. ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
  478. : ['images', 'videos'];
  479. const result = await ImagePicker.launchCameraAsync({
  480. mediaTypes,
  481. quality: 1,
  482. });
  483. if (result.canceled) return;
  484. onAdd(result.assets[0]?.uri ?? null);
  485. }
  486. function isVideoUri(uri: string) {
  487. const cleaned = uri.split('?')[0]?.toLowerCase() ?? '';
  488. return ['.mp4', '.mov', '.m4v', '.webm', '.mkv', '.avi'].some((ext) => cleaned.endsWith(ext));
  489. }
  490. function normalizeMediaUri(uri?: string | null) {
  491. if (!uri) return null;
  492. if (uri.startsWith('file://') || uri.startsWith('http://') || uri.startsWith('https://')) return uri;
  493. return `file://${uri}`;
  494. }
  495. function uniqueMediaUris(uris: string[]) {
  496. return Array.from(new Set(uris.filter(Boolean)));
  497. }
  498. function toDateOnly(date: Date) {
  499. const year = date.getFullYear();
  500. const month = String(date.getMonth() + 1).padStart(2, '0');
  501. const day = String(date.getDate()).padStart(2, '0');
  502. return `${year}-${month}-${day}`;
  503. }
  504. const styles = StyleSheet.create({
  505. container: { flex: 1 },
  506. keyboardAvoid: { flex: 1 },
  507. content: { padding: 16, paddingBottom: 40 },
  508. input: {
  509. borderWidth: 1,
  510. borderRadius: 12,
  511. paddingHorizontal: 12,
  512. paddingVertical: 10,
  513. marginTop: 6,
  514. marginBottom: 12,
  515. fontSize: 16,
  516. },
  517. multiline: { minHeight: 90, textAlignVertical: 'top' },
  518. errorText: { color: '#C0392B', marginBottom: 8 },
  519. requiredMark: { color: '#C0392B' },
  520. dateInput: {
  521. borderWidth: 1,
  522. borderRadius: 12,
  523. paddingHorizontal: 12,
  524. paddingVertical: 12,
  525. marginTop: 6,
  526. marginBottom: 12,
  527. },
  528. dateValue: { fontSize: 16 },
  529. chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 8 },
  530. chip: {
  531. paddingHorizontal: 12,
  532. paddingVertical: 6,
  533. borderRadius: 999,
  534. borderWidth: 1,
  535. borderColor: '#C9D2C4',
  536. backgroundColor: '#F8F6F0',
  537. },
  538. chipActive: { backgroundColor: '#DDE8DA', borderColor: '#88A68F' },
  539. chipText: { fontSize: 13 },
  540. mediaPreview: {
  541. width: '100%',
  542. height: 200,
  543. borderRadius: 16,
  544. backgroundColor: '#F1F1F1',
  545. marginBottom: 12,
  546. },
  547. mediaPlaceholder: {
  548. width: '100%',
  549. height: 200,
  550. borderRadius: 16,
  551. borderWidth: 1,
  552. justifyContent: 'center',
  553. alignItems: 'center',
  554. marginBottom: 12,
  555. },
  556. mediaPlaceholderText: { opacity: 0.7 },
  557. mediaRow: { marginBottom: 12 },
  558. mediaThumbWrap: { marginRight: 8 },
  559. mediaThumb: { width: 72, height: 72, borderRadius: 12 },
  560. mediaRemove: {
  561. position: 'absolute',
  562. top: -6,
  563. right: -6,
  564. width: 22,
  565. height: 22,
  566. borderRadius: 11,
  567. backgroundColor: '#2C2C2C',
  568. justifyContent: 'center',
  569. alignItems: 'center',
  570. },
  571. mediaRemoveText: { color: '#fff', fontSize: 14, lineHeight: 16 },
  572. updateGroup: {
  573. flexDirection: 'row',
  574. alignItems: 'center',
  575. gap: 8,
  576. },
  577. inlineToastText: {
  578. fontWeight: '700',
  579. fontSize: 12,
  580. },
  581. mediaButtons: { flexDirection: 'row', gap: 12, flexWrap: 'wrap', marginBottom: 16 },
  582. actions: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8 },
  583. modalOverlay: {
  584. flex: 1,
  585. backgroundColor: 'rgba(0,0,0,0.4)',
  586. justifyContent: 'center',
  587. padding: 20,
  588. },
  589. modalCard: {
  590. borderRadius: 16,
  591. borderWidth: 1,
  592. padding: 16,
  593. maxHeight: '70%',
  594. },
  595. modalItem: { paddingVertical: 10 },
  596. });